# frozen_string_literal: true

module EE
  module Vulnerability
    include ::Gitlab::Utils::StrongMemoize
    extend ActiveSupport::Concern

    prepended do
      include ::Ai::Model
      include ::CacheMarkdownField
      include ::Redactable
      include ::StripAttribute
      include ::Noteable
      include ::Mentionable
      include ::Awardable
      include ::Referable
      include ::Presentable
      include ::Gitlab::SQL::Pattern
      include ::VulnerabilityScopes
      include DatabaseEventTracking

      TooManyDaysError = Class.new(StandardError)

      MAX_DAYS_OF_HISTORY = 10
      ACTIVE_STATES = %w(detected confirmed).freeze
      PASSIVE_STATES = %w(dismissed resolved).freeze
      SUMMARY_DELIMITER = '|'
      REGEX_CAPTURING_STATUS = /\A[\w+\s]+to\s(?<status>\w+)/

      cache_markdown_field :title, pipeline: :single_line
      cache_markdown_field :description, issuable_reference_expansion_enabled: true

      strip_attributes! :title

      redact_field :description

      belongs_to :project # keep this association named 'project' for correct work of markdown cache
      belongs_to :milestone
      belongs_to :epic

      belongs_to :author, class_name: 'User' # keep this association named 'author' for correct work of markdown cache
      belongs_to :updated_by, class_name: 'User'
      belongs_to :last_edited_by, class_name: 'User'
      belongs_to :resolved_by, class_name: 'User'
      belongs_to :dismissed_by, class_name: 'User'
      belongs_to :confirmed_by, class_name: 'User'

      has_one :group, through: :project
      has_one :vulnerability_read, class_name: '::Vulnerabilities::Read'

      has_many :findings, class_name: '::Vulnerabilities::Finding', inverse_of: :vulnerability
      has_many :dismissed_findings, -> { dismissed }, class_name: 'Vulnerabilities::Finding', inverse_of: :vulnerability
      has_many :merge_request_links, class_name: '::Vulnerabilities::MergeRequestLink', inverse_of: :vulnerability
      has_many :merge_requests, through: :merge_request_links
      has_many :external_issue_links, class_name: '::Vulnerabilities::ExternalIssueLink', inverse_of: :vulnerability
      has_many :issue_links, class_name: '::Vulnerabilities::IssueLink', inverse_of: :vulnerability
      has_many :related_issues, through: :issue_links, source: :issue do
        def with_vulnerability_links
          select('issues.*, vulnerability_issue_links.id AS vulnerability_link_id, '\
                'vulnerability_issue_links.link_type AS vulnerability_link_type')
        end
      end
      has_many :state_transitions, class_name: '::Vulnerabilities::StateTransition', inverse_of: :vulnerability

      has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
      has_many :user_mentions, class_name: 'VulnerabilityUserMention'

      enum state: ::Enums::Vulnerability.vulnerability_states
      enum severity: ::Enums::Vulnerability.severity_levels, _prefix: :severity
      enum confidence: ::Enums::Vulnerability.confidence_levels, _prefix: :confidence
      enum report_type: ::Enums::Vulnerability.report_types

      validates :project, :author, :title, :severity, :report_type, presence: true

      # at this stage Vulnerability is not an Issuable, has some important attributes (and their constraints) in common
      validates :title, length: { maximum: ::Issuable::TITLE_LENGTH_MAX }
      validates :title_html, length: { maximum: ::Issuable::TITLE_HTML_LENGTH_MAX }, allow_blank: true
      validates :description, length: { maximum: ::Issuable::DESCRIPTION_LENGTH_MAX }, allow_blank: true
      validates :description_html, length: { maximum: ::Issuable::DESCRIPTION_HTML_LENGTH_MAX }, allow_blank: true

      scope :with_author_and_project, -> { includes(:author, :project) }
      scope :with_findings, -> { includes(:findings) }
      scope :with_state_transitions, -> { includes(:state_transitions) }
      scope :with_findings_by_uuid, -> (uuid) { with_findings.where(findings: { uuid: uuid }) }
      scope :with_findings_by_uuid_and_state, -> (uuid, state) { with_findings.where(findings: { uuid: uuid }, state: state) }
      scope :with_findings_excluding_uuid, -> (uuid) { joins(:findings).merge(Vulnerabilities::Finding.excluding_uuids(uuid)) }
      scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
      scope :with_findings_scanner_identifiers_and_notes, -> { with_findings_scanner_and_identifiers.includes(:notes) }
      scope :visible_to_user_and_access_level, -> (user, access_level) { where(project_id: ::Project.visible_to_user_and_access_level(user, access_level)) }
      scope :with_report_types, -> (report_types) { where(report_type: report_types) }
      scope :with_severities, -> (severities) { where(severity: severities) }
      scope :with_states, -> (states) { where(state: states) }
      scope :with_scanner_external_ids, -> (scanner_external_ids) { joins(findings: :scanner).merge(::Vulnerabilities::Scanner.with_external_id(scanner_external_ids)) }
      scope :grouped_by_severity, -> { reorder(severity: :desc).group(:severity) }
      scope :by_primary_identifier_ids, -> (identifier_ids) do
        joins(findings: :primary_identifier)
          .where(
            findings: {
              vulnerability_identifiers: {
                id: identifier_ids
              }
            }
          )
      end
      scope :by_project_fingerprints, -> (project_fingerprints) { joins(:findings).merge(Vulnerabilities::Finding.by_project_fingerprints(project_fingerprints)) }
      scope :by_scanner_ids, -> (scanner_ids) { joins(:findings).merge(::Vulnerabilities::Finding.by_scanners(scanner_ids)) }
      scope :created_in_time_range, ->(from: nil, to: nil) { where(created_at: from..to) }

      scope :with_resolution, -> (has_resolution = true) { where(resolved_on_default_branch: has_resolution) }
      scope :with_issues, -> (has_issues = true) do
        exist_query = has_issues ? 'EXISTS (?)' : 'NOT EXISTS (?)'
        issue_links = ::Vulnerabilities::IssueLink.arel_table

        where(exist_query, ::Vulnerabilities::IssueLink.select(1).where(issue_links[:vulnerability_id].eq(arel_table[:id])))
      end

      scope :autocomplete_search, -> (query) do
        return self if query.blank?

        id_as_text = Arel::Nodes::NamedFunction.new('CAST', [arel_table[:id].as('TEXT')])

        fuzzy_search(query, [:title])
          .or(where(id_as_text.matches("%#{sanitize_sql_like(query.squish)}%")))
      end

      scope :order_severity_asc, -> { reorder(severity: :asc, id: :desc) }
      scope :order_severity_desc, -> { reorder(severity: :desc, id: :desc) }
      scope :order_created_at_asc, -> { reorder(created_at: :asc, id: :desc) }
      scope :order_created_at_desc, -> { reorder(created_at: :desc, id: :desc) }
      scope :order_id_desc, -> { reorder(id: :desc) }

      scope :with_limit, -> (maximum) { limit(maximum) }
      scope :with_container_image, -> (images) do
        joins(:findings).merge(Vulnerabilities::Finding.by_location_image(images))
      end
      scope :with_cluster_ids, -> (cluster_ids) do
        joins(:findings).merge(Vulnerabilities::Finding.by_location_cluster(cluster_ids))
      end
      scope :with_cluster_agent_ids, -> (agent_ids) do
        joins(:findings).merge(Vulnerabilities::Finding.by_location_cluster_agent(agent_ids))
      end

      scope :for_default_branch, -> (present_on_default_branch = true) { where(present_on_default_branch: present_on_default_branch) }
      scope :present_on_default_branch, -> { where('present_on_default_branch IS TRUE') }

      scope :by_ids, ->(ids) { where(id: ids) }
      scope :by_age, ->(operator, days) do
        case operator
        when :greater_than then created_before(days.days.ago)
        when :less_than then created_after(days.days.ago)
        end
      end

      scope :created_after, ->(days_ago) { where(model.arel_table[:created_at].gteq(days_ago)) }
      scope :created_before, ->(days_ago) { where(model.arel_table[:created_at].lteq(days_ago)) }

      delegate :scanner_name, :scanner_external_id, :scanner_id, :metadata, :description, :description_html, :details, :uuid,
        to: :finding, prefix: true, allow_nil: true

      delegate :default_branch, :name, to: :project, prefix: true, allow_nil: true
      delegate :name, to: :group, prefix: true, allow_nil: true

      delegate :solution, :identifiers, :links, :remediations, :file,
        :cve_value, :cwe_value, :other_identifier_values, :location,
        to: :finding, allow_nil: true

      delegate :file, to: :finding, prefix: true, private: true

      def full_path
        "#{project.full_path}/#{id}"
      end

      def to_reference(from = nil, full: false)
        project
          .to_reference_base(from, full: full)
          .then { |reference_base| reference_base.present? ? "#{reference_base}/" : nil }
          .then { |reference_base| "#{self.class.reference_prefix}#{reference_base}#{id}#{self.class.reference_postfix}" }
      end

      # There will only be one finding associated with a vulnerability for the foreseeable future
      def finding
        findings.first
      end

      def has_remediations?
        remediations&.any? || false
      end

      def resource_parent
        project
      end

      def discussions_rendered_on_frontend?
        true
      end

      def user_notes_count
        user_notes_count_service.count
      end

      def after_note_changed(note)
        user_notes_count_service.delete_cache unless note.system?
      end
      alias_method :after_note_created,   :after_note_changed
      alias_method :after_note_destroyed, :after_note_changed

      def stat_diff
        ::Vulnerabilities::StatDiff.new(self)
      end

      def blob_path
        return unless finding_file

        ::Gitlab::Routing.url_helpers.project_blob_path(project, File.join(finding.sha, finding_file))
      end

      def execute_hooks
        project.execute_integrations(integration_data, :vulnerability_hooks)
      end

      def notes_summary
        @notes_summary ||= discussions.map do |discussion|
          status = extracted_status(discussion.notes.first.note)
          discussion.notes.map do |note|
            note_as_string(note.last_edited_at.utc, note.updated_by_or_author.username, status, note.note)
          end
        end.join('; ')
      end

      def latest_state_transition
        @latest_state_transition ||= state_transitions.last
      end

      private

      def note_as_string(time, username, status, text)
        CSV.generate(col_sep: SUMMARY_DELIMITER) do |csv|
          csv << [time, username, status, text]
        end
      end

      def extracted_status(note)
        match_data = note.match(REGEX_CAPTURING_STATUS)
        return '' unless match_data

        match_data[:status]
      end

      def integration_data
        @integration_data ||= ::Gitlab::DataBuilder::Vulnerability.build(self)
      end

      def user_notes_count_service
        @user_notes_count_service ||= ::Vulnerabilities::UserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
      end
    end

    class_methods do
      def reference_pattern
        @reference_pattern ||= %r{
          #{Regexp.escape(reference_prefix)}(#{::Project.reference_pattern}\/)?(?<vulnerability>\d+)#{Regexp.escape(reference_postfix)}
        }x
      end

      def link_reference_pattern
        %r{
          (?<url>
            #{Regexp.escape(::Gitlab.config.gitlab.url)}
            \/#{::Project.reference_pattern}
            (?:\/\-)
            \/security\/vulnerabilities
            \/(?<vulnerability>\d+)
            (?<path>
              (\/[a-z0-9_=-]+)*\/*
            )?
            (?<anchor>\#[a-z0-9_-]+)?
          )
        }x
      end

      def parent_class
        ::Project
      end

      def projects
        ::Project.id_in(select(:project_id))
      end

      def to_ability_name
        model_name.singular
      end

      def report_type_order
        report_types
          .sort
          .to_h
          .values
          .each
          .with_index
          .reduce(Arel::Nodes::Case.new(arel_table[:report_type])) do |node, (value, index)|
            node.when(value).then(index)
          end
      end

      def state_order
        Arel::Nodes::NamedFunction.new(
          'ARRAY_POSITION',
          [
            Arel.sql("ARRAY#{states.values}::smallint[]"),
            arel_table[:state]
          ]
        )
      end

      def active_states
        ACTIVE_STATES
      end

      def passive_states
        PASSIVE_STATES
      end

      def active_state_values
        states.values_at(*active_states)
      end

      def order_by(method)
        case method.to_s
        when 'severity_desc' then order_severity_desc
        when 'severity_asc' then order_severity_asc
        when 'detected_desc' then order_created_at_desc
        when 'detected_asc' then order_created_at_asc
        else
          order_severity_desc
        end
      end

      def with_keyset_order(arel_function, name, direction, tie_breaker_direction = nil)
        raise "unknown sort direction given: #{direction}" unless %i[asc desc].include?(direction)

        if tie_breaker_direction.present? && !%i[asc desc].include?(tie_breaker_direction)
          raise "unknown tie breaker sort direction given: #{tie_breaker_direction}"
        end

        ::Gitlab::Pagination::Keyset::Order.build(
          [
            ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
              attribute_name: name,
              order_expression: arel_function.public_send(direction), # rubocop: disable GitlabSecurity/PublicSend
              nullable: :not_nullable,
              order_direction: direction,
              distinct: false,
              add_to_projections: true
            ),
            ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
              attribute_name: 'id',
              order_expression: arel_table[:id].public_send(tie_breaker_direction || direction), # rubocop: disable GitlabSecurity/PublicSend
              nullable: :not_nullable,
              distinct: true
            )
          ])
      end
    end

    SNOWPLOW_ATTRIBUTES = %i[
      id
      confidence
      confidence_overridden
      confirmed_at
      created_at
      dismissed_at
      resolved_at
      severity_overridden
      state
      updated_at
    ].freeze
  end
end
